#
# Copyright 2008-2010 Vadim Zhukov <persgray@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY AGAVA COMPANY ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
# EVENT SHALL THE AGAVA COMPANY OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and documentation are
# those of the authors and should not be interpreted as representing official
# policies, either expressed or implied, of the AGAVA Company.

# This package used to process RB profiles. It opens, reads and checks all
# profile parameteres, and aborts program when error found.
package RB::Profile;

use bytes;
use strict;

use Text::ParseWords qw/shellwords/;
use RB::Util;

my %DefaultGlobal = (
	abort_if_exists => 1,
	abort_if_free_abs => undef,
	abort_if_free_percent => '97',
	autorotate => 1,
	archive_dir => '~/dumps/rb',
	incremental_daily => 0,
	incremental_weekly => 0,
	max_retry_count => 30,
	monthly_dump_at => 1,               # First day of month
	network_timeout => 30,
	retry_wait => 60,
# 	ssh_identity => '~/.ssh/id_rb_cpanel',
# 	ssh_port => '22',
# 	ssh_user => 'rb',
	weekly_dump_at => 0                 # Every Sunday
	);

sub new($$) {
	my ($class, $filePath) = (shift, shift);

	my $this = {};
	bless($this, $class);

	if (!open(PROFILE, '<', $filePath)) {
		die "Cannot open profile $filePath: $!";
	}

	my $curModuleName;
	my $backupModuleList = [];    # Save order modules appear in config file
	my $concatLine = '';
READPROFILELINE:
	while (my $line = <PROFILE>) {
		chomp $line;

		# Allow splitting long lines via "\"
		if ($line =~ /\\$/) {
			$concatLine .= substr($line, 0, length($line) - 1);
			next;
		} else {
			$line = $concatLine.$line;
			$concatLine = '';
		}

		# Scan line foor allowed patterns
		if ($line =~ /^\s*#/s or $line =~ /^\s*$/s) {
			# Skip comments and blank lines
			next;
		} elsif ($line =~ /^\s*\[([\w_-]+)[^\]]*\]\s*(#.*)?$/s) {
			# Skip on first module declaration
			$this->_fixateModule($curModuleName)
			    if defined $curModuleName;
			$curModuleName = lc $1;
			# Do not allow non-contigious modules
			if (exists $this->{$curModuleName}) {
				die "SYNTAX ERROR: module $curModuleName is ".
				    "already defined on profile line $.";
			}
			push(@{$backupModuleList}, $curModuleName)
			    if $curModuleName ne 'global';
			# Shortens many functions argument lists
			$this->{$curModuleName} = { 'name' => $curModuleName };
		} elsif ($line =~ /^\s*([\w_-]+)\s*=\s*(\S+(\s+\S+)*)\s*$/s) {
			unless (defined $curModuleName) {
				die "SYNTAX ERROR: directive \"$1\" without ".
				    "module on line $.. Did you forget ".
				    "[global]?";
			}
			my @words = shellwords $2;
			until (@words) {
				# Non-closed quotes.
				# Text::ParseWords::shellwords() could also
				# return undef if there are no tokens in string
				# (surely, this is a bug), but \S in $line test
				# regex above make sure there is at least one
				# non-whitespace character and, therefore,
				# at least one word.
				#
				# Yes, we have to re-check line each time
				# we get here.
				$concatLine = $line;
				$line = <PROFILE>;
				last READPROFILELINE unless defined $line;
				chomp $line;
				$line = $concatLine.$line;
				$line =~
				    /^\s*([\w_-]+)\s*=\s*(\S+(\s+\S+)*)\s*$/s;
				@words = shellwords $2;
				if (@words and $line =~ /\\$/) {
					next READPROFILELINE;
				}
			}
			$concatLine = '';
			$this->_addDirective($curModuleName, lc $1, $2, @words);
		} else {
			die "SYNTAX ERROR: see profile line $.: $_";
		}
	}
	if ($concatLine ne '') {
		# Last character was concatination character, or
		# quotes were not closed.
		die "SYNTAX ERROR: last profile line is not ended correctly";
	}
	$this->_fixateModule($curModuleName);

	# Dance with hashes because directives like "abort_if_free*" needs
	#  accurate handling
	$this->{'global'} = {} unless exists $this->{'global'};
	my $combinedGlobal = { %DefaultGlobal };
	my $global = $this->{'global'};
	foreach my $d (keys %{$global}) {
		$combinedGlobal->{$d} = $global->{$d};
	}
	$this->{'global'} = $combinedGlobal;
	$this->{'global'}->{'backup_modules'} = $backupModuleList;

	foreach my $moduleName (@{$backupModuleList}) {
		foreach my $opt (qw/autorotate incremental_daily
		    incremental_weekly max_retry_count monthly_dump_at
		    retry_wait weekly_dump_at/) {
			unless (exists $this->{$moduleName}->{$opt}) {
				$this->{$moduleName}->{$opt} =
				    $this->{'global'}->{$opt};
			}
		}
	}

	return $this;
}

sub _failDirectiveValue($$) {
	my ($name, $value) = @_;
	die "SYNTAX ERROR: Illegal directive \"$name\" value: \"$value\" on ".
	    "profile line $.";
}

sub _failDirectiveModule($$) {
	my ($name, $curModuleName) = @_;
	die "SYNTAX ERROR: Directive \"$name\" cannot present in ".
	    "\"$curModuleName\" module, see profile line $.";
}

my $reDumpAt = "([0-9]+)|".
               "(sun(day)?|mon(day)?|tue(sday)?|wed(nesday)?|".
                "thu(rsday)?|fri(day)?|sat(urday)?)|".
               "hash|round-robin";

sub _addDirective($$$$) {
	my ($this, $curModuleName, $name, $rawValue) =
	    (shift, shift, lc shift, shift);

	my $module = $this->{$curModuleName};

	# Both global- and module-level directives
	if ($name eq 'max_retry_count') {
		# Non-negative number
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^[0-9]+$/s;
		$module->{$name} = $value;
	} elsif ($name eq 'retry_wait') {
		# Time interval
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^(([0-9]+)H)?(([0-9]+)M)?(([0-9]+)S?)?$/i;
		$module->{$name} = $6+0;
		$module->{$name} += $4 * 60   if defined $4;
		$module->{$name} += $2 * 3600 if defined $2;
	# Global directives
	} elsif ($name eq 'archive_dir' or
		$name eq 'ssh_identity' or
		$name eq 'ssh_user'
		) {
		# Text directive
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName ne 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		$module->{$name} = $_[0];
	} elsif ($name eq 'abort_if_exists' or
		$name eq 'reserve_space' or
		$name eq 'autorotate'
		) {
		# Boolean
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName ne 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = lc $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~
		    /^true|false|yes|no|on|off|enabled|disabled$/is;
		if ($value eq 'false' or $value eq 'no' or $value eq 'off' or
		    $value eq 'disabled') {
			$value = 0;
		} else {
			$value = 1;
		}
		if ($name ne 'reserve_space') {
			$module->{$name} = $value;
		} else {
			print STDERR "SYNTAX WARNING: Deprecated option used: ".
			    $name.", ignored\n";
		}
	} elsif ($name eq 'abort_if_free') {
		# File size or per cents
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName ne 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^([0-9]+)(%|K|M|G|T)?$/s;
		if ($2 eq '%') {
			$module->{$name.'_abs'} = undef;
			$module->{$name.'_percent'} = $1;
		} else {
			$module->{$name.'_abs'} =
			    $1 * calcFileSizeMultiplier($2);
			_failDirectiveValue($name, $value)
			    if $module->{$name.'_abs'} == 0 && $1 != 0;
			$module->{$name.'_percent'} = undef;
		}
	} elsif ($name eq 'notify_step') {
		# File size
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName ne 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^([0-9]+)(K|M|G|T)?$/s;
		print STDERR "SYNTAX WARNING: Deprecated option used: ".
		    $name.", ignored\n";
	} elsif ($name eq 'min_remote_time' or
	    $name eq 'network_timeout') {
		# Time interval
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName ne 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^(([0-9]+)H)?(([0-9]+)M)?(([0-9]+)S?)?$/i;
		$module->{$name} = $6+0;
		$module->{$name} += $4 * 60   if defined $4;
		$module->{$name} += $2 * 3600 if defined $2;
	} elsif ($name eq 'ssh_port') {
		# TCP/UDP port number
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName ne 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^[0-9]+$/s or $value < 1 or $value > 65535;
		$module->{$name} = $value;
	# Backup directives
	} elsif ($name eq 'dump_as' or
		$name eq 'restore_as' or
		$name eq 'socket' or
		$name eq 'workdir'
		) {
		# Text directive
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		if ($name eq 'restore_as') {
			print STDERR "SYNTAX WARNING: Deprecated option used: ".
			    $name.", ignored\n";
		} else {
			$module->{$name} = $_[0];
		}
	} elsif ($name eq 'which') {
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		$module->{$name} = [ @_ ];
	} elsif ($name eq 'incremental_daily' or
		$name eq 'incremental_weekly' or
		$name eq 'separate_archives'
		) {
		# Boolean
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = lc $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~
		    /^true|false|yes|no|on|off|enabled|disabled$/is;
		if ($value eq 'false' or $value eq 'no' or $value eq 'off' or
		    $value eq 'disabled') {
			$value = 0;
		} else {
			$value = 1;
		}
		$module->{$name} = $value;
	} elsif ($name eq 'what') {
		# Backup what list
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = lc $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^files|mysql|postgres|fs$/s;
		$module->{$name} = $value;
	} elsif ($name eq 'compress') {
		# Number in range 0..9 or boolean
		# Use special mark "+" because GZip compression level
		#  can not be easily determined (could be overriden by
		#  "GZIP" enviornment variable, for example)
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = lc $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~
		    /^[0-9]|true|false|yes|no|on|off|enabled|disabled$/is;
		if ($value eq 'false' or $value eq 'no' or $value eq 'off' or
		    $value eq 'disabled') {
			$value = 0;
		} elsif ($value eq 'true' or $value eq 'yes' or
		     $value eq 'on' or $value eq 'enabled') {
			$value = '+';
		}
		$module->{$name} = $value;
	} elsif ($name eq 'rotate') {
		# Rotate scheme, date intervals
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = lc $_[0];
		_failDirectiveValue($name, $rawValue)
		    if $value !~ /^(([0-9]+)m)?(([0-9]+)w)?(([0-9]+)d)?$/s;
		$module->{$name.'_months'} = $2 || 0;
		$module->{$name.'_weeks'} = $4 || 0;
		$module->{$name.'_days'} = $6 || 0;
	} elsif ($name eq 'archive_format') {
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $value = lc $_[0];
		# We'll check it later, in _fixateModule(), because we need to
		# know "what" value
		$module->{$name} = $value;
	} elsif ($name eq 'monthly_dump_at') {
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		my ($mday, $wday, $mweek);
		my $n = scalar(@_);
		if ($n == 1) {
			# Desired month day (1-based)
			$mday = lc $_[0];
		} elsif ($n == 2) {
			# Week set by user, week day set to default (Sunday)
			_failDirectiveValue($name, $rawValue)
			    if lc($_[0]) ne 'week';
			$mweek = lc $_[1];
			$wday = 0;
		} elsif ($n == 3) {
			_failDirectiveValue($name, $rawValue)
			    if lc($_[1]) ne 'week';
			($wday, $mweek) = (lc($_[0]), lc($_[2]));
		} else {
			_failDirectiveValue($name, $rawValue);
		}

		if (defined $mday) {
			_failDirectiveValue($name, $rawValue)
			    if $mday !~ /$reDumpAt/;
			_failDirectiveValue($name, $rawValue)
			    if $1 ne '' and $mday == 0;
			$module->{$name.'_mday'} = $mday + 1;
		} else {
			if (defined $mweek) {
				_failDirectiveValue($name, $rawValue)
				    if $mweek !~ /([0-9]+)|hash|round-robin/;
				_failDirectiveValue($name, $rawValue)
				    if $1 ne '' and $mweek == 0;
				$module->{$name.'_mweek'} = $mweek + 1;
			} else {
				$module->{$name.'_mweek'} = 1;
			}
			if (defined $wday) {
				_failDirectiveValue($name, $rawValue)
				    if $wday !~ /$reDumpAt/;
				_failDirectiveValue($name, $rawValue)
				    if $1 ne '' and $wday == 0;
				$module->{$name.'_wday'} = $wday;
			} else {
				$module->{$name.'_wday'} = 0;
			}
			# No need to check if at least one of $mweek and $wday
			# was defined - all such cases we handled earlier.
		}
	} elsif ($name eq 'weekly_dump_at') {
		_failDirectiveModule($name, $curModuleName)
		    if $curModuleName eq 'global';
		_failDirectiveValue($name, $rawValue)
		    if scalar(@_) > 1;
		my $day = lc($_[0]);
		_failDirectiveValue($name, $rawValue)
		    if $day !~ /$reDumpAt/;
		$module->{$name.'_wday'} = $day;
	} else {
		print STDERR "SYNTAX WARNING: Unknown directive: $name on ".
		    "profile line $.\n";
	}
}

sub _fixateModule($$) {
	my ($this, $moduleName) = (shift, shift);

	my $module = $this->{$moduleName};
	if ($moduleName ne 'global') {
		foreach my $vname ('what', 'which') {
			if (!$module->{$vname}) {
				die "SYNTAX ERROR: No \"${vname}\" directive".
				    "in backup module $moduleName\n";
			}
		}

		if ($module->{'what'} ne 'files'
		    and exists $module->{'workdir'}) {
			print STDERR "SYNTAX WARNING: working directory is ".
			    "meaningful only for file dumps, ignored for ".
			    "module $moduleName\n";
			delete $module->{'workdir'};
		}

		if (!exists $module->{'rotate_days'}) {
			$module->{'rotate_days'} = 1;
			$module->{'rotate_weeks'} = 1;
			$module->{'rotate_months'} = 1;
		} elsif ($module->{'rotate_days'} == 0
		    and $module->{'rotate_weeks'} == 0
		    and $module->{'rotate_months'} == 0) {
			die "SYNTAX ERROR: Empty rotate scheme entered in ".
			    "backup module $moduleName";
		}

		if (exists $module->{'archive_format'}) {
			if ($module->{'what'} eq 'files'
			    and $module->{'archive_format'} !~ /^cpio|tar$/) {
				die "SYNTAX ERROR: Only allowed values for ".
				    "archive_format when backing up files".
				    " are \"cpio\" and \"tar\".";
			} elsif ($module->{'what'} eq 'mysql'
			    and $module->{'archive_format'} ne 'plain') {
				die "SYNTAX ERROR: Only allowed value for ".
				    "archive_format when backing up MySQL".
				    " is \"plain\".";
			} elsif ($module->{'what'} eq 'postgres'
			    and $module->{'archive_format'} !~
			    /^custom|plain|tar$/) {
				die "SYNTAX ERROR: Only allowed values for ".
				    "archive_format when backing up PostgreSQL".
				    " are \"custom\", \"plain\" and \"tar\".";
			}
		} elsif (!exists $module->{'archive_format'}) {
			# Set default
		    	if ($module->{'what'} eq 'files') {
				$module->{'archive_format'} = 'cpio';
			} elsif ($module->{'what'} eq 'mysql') {
				$module->{'archive_format'} = 'plain';
			} elsif ($module->{'what'} eq 'postgres') {
				$module->{'archive_format'} = 'tar';
			}
		}

		if (($module->{'incremental_daily'} or
		    $module->{'incremental_weekly'}) and
		    $module->{'what'} ne 'files') {
			die "SYNTAX ERROR: incremental_daily and ".
			    "incremental_weekly options may be specified for ".
			    "files module archives only";
		}

		unless ((exists $module->{'monthly_dump_at_mweek'} and
		    exists $module->{'monthly_dump_at_wday'}) or
		    exists $module->{'monthly_dump_at_mday'}) {
			$module->{'monthly_dump_at_mday'} = 1;
		}
		unless (exists $module->{'weekly_dump_at_wday'}) {
			$module->{'weekly_dump_at_wday'} = 0;
		}

		# Simplify using by precalculating some useful values
		$module->{'archive_ext'} = constructArchiveExt(
		    $module->{'what'},
		    $module->{'archive_format'},
		    $module->{'compress'});
	}
}

# Return hash of options that can be safely transferred to agent
my %AgentOptions = (
	archive_ext => 1,
	archive_format => 1,
	dump_as => 1,
	max_retry_count => 1,
	restore_as => 1,
	separate_archives => 1,
	what => 1,
	workdir => 1
);
sub agentOptions($$) {
	my ($this, $moduleName) = (shift, shift);
	my $opts = { %{$this->{'global'}} };
	unless ($moduleName eq '') {
		die "Unexisting module $moduleName"
		    if $moduleName eq 'global' or !exists $this->{$moduleName};
		$opts = { %{$opts}, %{$this->{$moduleName}} };
	}
	foreach (keys %{$opts}) {
		delete $opts->{$_} unless exists $AgentOptions{$_};
	}
	return $opts;
}

1;
